#! user/bin/python
# -*- coding: utf-8 -*-

"""
Created on 2021/8/31 10:58

@File: 邮件列表窗口_ListWindows.py
@Author: LT
# -----------------------------------------------------------------------------------------------------
@Description: 
实现邮件服务器和本地保存邮件的主列表窗口：每类窗口对应一个类。
此处提取了公共代码，以供复用：服务器和文件列表窗口是PyMailCommon列表窗口类的自定义版本；
服务器窗口对应从服务器传输邮件的相关操作，文件窗口则应用于本地文件的操作。
为响应用户行为，列表窗口创建View、Write、Reply和Forward窗口。

服务器列表窗口是程序通过运行顶层文件而启动的主窗口；文件列表窗口则通过服务器和文件列表窗口中的Open，在需要时打开。
如果POP收件箱发生了更改，那么Msgnums可能暂时与服务器不同步（在这个程序里将出发彻底重载）。
——现在在删除和加载时检查msgnums是否与服务器同步
——在查看窗口里添加最多N个直接访问附件的按钮
——将本地邮件保存文件的加载线程化，以避免大文件加载阻塞GUI
——本地邮件保存文件的删除也线程化以避免文件写入阻塞GUI

待定:
——本地邮件保存文件的保存仍然没有线程化：可能会短暂地阻塞GUI，但这种情况并不常见
——与加载和删除不同，保存/发送尽在本地文件末尾追加内容。
——将本地邮件保存文件实现为带有分隔符地文本文件大体上还是一个原型：它将所有邮件整体加载到内存里，这样一来，为了操作合理，这些文件的大小收到了限制；
更好的替代方案为：为题头和全文使用两个不同的文件，由DBM键进行访问，在用一个列表来把键映射为未知；在这个方案里，本地邮件保存文件变成了目录而不再能读取。
# -----------------------------------------------------------------------------------------------------
"""

from .程序范围内的全局对象_SharedNames import *                                       # 程序范围内的全局对象
from .邮件查看窗口_ViewWindows import ViewWindow, WriteWindow, ReplyWindow, ForwardWindow


###############################################################################
# 主框架——用于文件和服务器邮件列表的通用框架
###############################################################################


class PyMailCommon(mailtools.MailParser):
    """
    带有主邮件列表框的抽象组件包；
    在顶层窗口类里混用了Tk，Toplevel或Frame；
    在不同类型的子类里，必须用action()等方法进行定制；
    根据需要创建查看和撰写窗口：它们作为MailSenders类发挥作用；
    """
    # 在所有列表窗口间共享的类属性
    threadLoopStarted = False                                                   # 由前一个窗口启动
    queueChecksPerSecond = 20                                                   # 如果CPU使用率过高则做微调
    queueDelay = 1000 // queueChecksPerSecond                                   # 计时器事件间隔的最短长度（毫秒）
    queueBatch = 5                                                              # 每个计时器事件的最大回调数

    # 所有窗口都使用同一个对话框：记住上一次的目录
    openDialog = Open(title=appname + ': Open Mail File')
    saveDialog = SaveAs(title=appname + ': Append Mail File')

    # 避免并行下载（抓取）同一封邮件
    beingFetched = set()

    def __init__(self):
        self.makeWidgets()                                                      # 绘制内容：列表，工具
        if not PyMailCommon.threadLoopStarted:
            # 启动线程退出检查循环
            # 计时器事件循环，负责调度队列中的GUI回调；
            # 所有窗口都是用同一个循环：服务器，文件，查看都可以衍生线程；
            # self是Tk，Toplevel或Frame类：可以使用任何组件类型；
            # 为了提升运行速度添加了队列延迟/批处理特性：~100x/sec；

            PyMailCommon.threadLoopStarted = True
            threadtools.threadChecker(self, self.queueDelay, self.queueBatch)

    def makeWidgets(self):
        # 在底部添加了all/none单选框
        tools = Frame(self, relief=SUNKEN, bd=2, cursor='hand2')
        tools.pack(side=BOTTOM, fill=X)
        self.allModeVar = IntVar()
        chk = Checkbutton(tools, text="All")
        chk.config(variable=self.allModeVar, command=self.onCheckAll)
        chk.pack(side=RIGHT)

        # 在底部工具栏添加了主要按钮
        for (title, callback) in self.actions():                                # 如果没有用到强要求属性定义的话，并不是很能和我的意
            if not callback:
                sep = Label(tools, text=title)                                  # 分隔符
                sep.pack(side=LEFT, expand=YES, fill=BOTH)                      # 随窗口扩展
            else:
                Button(tools, text=title, command=callback).pack(side=LEFT)

        # 添加带有滚动条的多选列表框
        listwide = mailconfig.listWidth or 74                                   # 配置启动时的大小；wide指字符数，high指行数
        listhigh = mailconfig.listHeight or 15
        mails = Frame(self)
        vscroll = Scrollbar(mails)
        hscroll = Scrollbar(mails, orient='horizontal')
        fontsz = (sys.platform[:3] == 'win' and 8) or 10                        # 默认设置
        listbg = mailconfig.listbg or 'white'
        listfg = mailconfig.listfg or 'black'
        listfont = mailconfig.listfont or ('courier', fontsz, 'normal')
        listbox = Listbox(mails, bg=listbg, fg=listfg, font=listfont)
        listbox.config(selectmode=EXTENDED)
        listbox.config(width=listwide, height=listhigh)                         # 初始化时宽度增加了
        listbox.bind('<Double-1>', (lambda event: self.onViewRawMail()))

        # 实现滚动的关键设置————交叉关联滚动条与列表盒子
        vscroll.config(command=listbox.yview, relief=SUNKEN)
        hscroll.config(command=listbox.xview, relief=SUNKEN)
        listbox.config(yscrollcommand=vscroll.set, relief=SUNKEN)
        listbox.config(xscrollcommand=hscroll.set)

        # 越是最后打包的，越是最先被挤出
        mails.pack(side=TOP, expand=YES, fill=BOTH)
        vscroll.pack(side=RIGHT, fill=BOTH)
        hscroll.pack(side=BOTTOM, fill=BOTH)
        listbox.pack(side=LEFT, expand=YES, fill=BOTH)
        self.listBox = listbox

    #################
    # 事件处理器
    #################

    def onCheckAll(self):
        # 在全选或全部取消之间单机切换
        if self.allModeVar.get():
            self.listBox.select_set(0, END)
        else:
            self.listBox.select_clear(0, END)

    def onViewRawMail(self):
        # 可能在派生线程里运行：查看选定的邮件——原始文本邮件题头和主体
        msgnums = self.verifySelectedMsgs()
        if msgnums:
            self.getMessages(msgnums, after=lambda: self.contViewRaw(msgnums))

    def contViewRaw(self, msgnums, pyedit=True):                                # 我们需要完整的TextEditor吗？
        for msgnum in msgnums:                                                  # 可以是个嵌套的函数
            fulltext = self.getMessage(msgnum)                                  # fulltext 是 Unicode 解码过的
            if not pyedit:
                # 会在 scrolledtext 中显示
                from tkinter.scrolledtext import ScrolledText
                window = windows.QuietPopupWindow(appname, 'raw message viewer')
                browser = ScrolledText(window)
                browser.insert('0.0', fulltext)
                browser.pack(expand=YES, fill=BOTH)
            else:
                # 更好用的 PyEdit text editor
                wintitle = ' - raw message text'
                browser = textEditor.TextEditorMainPopup(self, winTitle=wintitle)
                browser.update()
                browser.setAllText(fulltext)
                browser.clearModified()

    def onViewFormatMail(self):
        """
        可能在派生线程里运行：查看选定的邮件——如果邮件位于本地邮件保存列表或者已加载，则弹出非线程化的格式化显示窗口；
        after行为仅在允许并成功进行getMessages预抓取之后运行。
        """
        msgnums = self.verifySelectedMsgs()
        if msgnums:
            self.getMessages(msgnums, after=lambda: self.contViewFmt(msgnums))

    def contViewFmt(self, msgnums):
        """
        完成View操作：提取著文本，弹出窗口来显示邮件；必要时从html文本中提取纯文本，给文本换行；
        html邮件：显示提取的文本，保存在临时文件中，然后在网页浏览器中打开；
        也可以通过查看窗口的Split或组分按钮手动打开组分；
        如果邮件类型是non-multipart,other:必须用Split或组分按钮手动打开组分；
        由mailconfig负责验证html的打开；

        对于只有HTML的邮件。这里的著文本是str，但以二进制模式来保存其原始字节以方便编码；
        这段代码不无价值，现在许多邮件都包含html；
        以前的做法是尝试N个编码去猜测，如（utf-8,latin-1,平台默认编码等），但现在获取并保存原始字节，以尽量减少邮件内容失真；
        如果稍后组分应请求而打开，那么它会以相同的方式以原始字节形式保存在二进制文件里；

        不足之处：所派生的网页浏览器不会含有任何原始电子邮件题头：
        它可能依然需要猜测或被告知编码，除非html已经包含自己的编码题头，如在<head>的<meta>标签里；
        """
        for msgnum in msgnums:
            fulltext = self.getMessage(msgnum)                                  # 待解析的str
            message = self.parseMessage(fulltext)
            type, content = self.findMainText(message)                          # 经过Unicode解码
            if type in ['text/html', 'text/xml']:                               # 获取纯文本
                content = html2text.html2text(content)
            content = wraplines.wrapText1(content, mailconfig.wrapsz)
            ViewWindow(headermap=message,
                       showtext=content,
                       origmessage=message)                                     # 解码邮件题头

            # 非多组分，content-type为text/HTML（很原始但很真实！）
            if type == 'text/html':
                if ((not mailconfig.verifyHTMLTextOpen) or
                        askyesno(appname, 'Open message text in browser?')):

                    # 根据前面的unicode解码字节获取mime解码后的邮件文本
                    type, asbytes = self.findMainText(message, asStr=False)
                    try:
                        from tempfile import gettempdir                         # 或者用Tk的HTML查看器？
                        tempname = os.path.join(gettempdir(), 'pymailgui.html')
                        tmp = open(tempname, 'wb')                              # 已经经过解码的
                        tmp.write(asbytes)
                        tmp.close()                                             # 直接刷进硬盘里
                        webbrowser.open_new('file://' + tempname)
                    except:
                        showerror(appname, 'Cannot open in browser')

    def onWriteMail(self):
        """
        从头开始撰写新的电子邮件，不抓取其他邮件；
        这里没有原文可饮用，但是如果在mailconfig里启动了相应的可选题头，那么会添加签名，还会用发件人地址预先填充Bcc；
        From可能在mailcinfig里进行了i8n（国家化）编码；
        查看窗口会进行解码的；
        """
        starttext = '\n'                                                        # use auto signature text
        if mailconfig.mysignature:
            starttext += '%s\n' % mailconfig.mysignature
        From = mailconfig.myaddress
        WriteWindow(starttext=starttext,
                    headermap=dict(From=From, Bcc=From))                        # 3.0: prefill bcc

    def onReplyMail(self):
        # possibly threaded: reply to selected emails
        msgnums = self.verifySelectedMsgs()
        if msgnums:
            self.getMessages(msgnums, after=lambda: self.contReply(msgnums))

    def contReply(self, msgnums):
        """
        finish Reply: drop attachments, quote with '>', add signature;
        presets initial to/from values from mail or config module;
        don't use original To for From: may be many or a listname;
        To keeps name+<addr> format even if ',' separator in name;
        Uses original From for To, ignores reply-to header is any;
        3.0: replies also copy to all original recipients by default;

        3.0: now uses getaddresses/parseaddr full parsing to separate
        addrs on commas, and handle any commas that appear nested in
        email name parts;  multiple addresses are separated by comma
        in GUI, we copy comma separators when displaying headers, and
        we use getaddresses to split addrs as needed;  ',' is required
        by servers for separator;  no longer uses parseaddr to get 1st
        name/addr pair of getaddresses result: use full From for To;

        3.0: we decode the Subject header here because we need its text,
        but the view window superclass of edit windows performs decoding
        on all displayed headers (the extra Subject decode is a no-op);
        on sends, all non-ASCII hdrs and hdr email names are in decoded
        form in the GUI, but are encoded within the mailtools package;
        quoteOrigText also decodes the initial headers it inserts into
        the quoted text block, and index lists decode for display;
        """
        for msgnum in msgnums:
            fulltext = self.getMessage(msgnum)
            message = self.parseMessage(fulltext)  # may fail: error obj
            maintext = self.formatQuotedMainText(message)  # same as forward

            # from and to are decoded by view window
            From = mailconfig.myaddress  # not original To
            To = message.get('From', '')  # 3.0: ',' sept
            Cc = self.replyCopyTo(message)  # 3.0: cc all recipients?
            Subj = message.get('Subject', '(no subject)')
            Subj = self.decodeHeader(Subj)  # deocde for str
            if Subj[:4].lower() != 're: ':  # 3.0: unify case
                Subj = 'Re: ' + Subj
            ReplyWindow(starttext=maintext,
                        headermap=
                        dict(From=From, To=To, Cc=Cc, Subject=Subj, Bcc=From))

    def onFwdMail(self):
        # possibly threaded: forward selected emails
        msgnums = self.verifySelectedMsgs()
        if msgnums:
            self.getMessages(msgnums, after=lambda: self.contFwd(msgnums))

    def contFwd(self, msgnums):
        """
        finish Forward: drop attachments, quote with '>', add signature;
        see notes about headers decoding in the Reply action methods;
        view window superclass will decode the From header we pass here;
        """
        for msgnum in msgnums:
            fulltext = self.getMessage(msgnum)
            message = self.parseMessage(fulltext)
            maintext = self.formatQuotedMainText(message)  # same as reply

            # initial From value from config, not mail
            From = mailconfig.myaddress  # encoded or not
            Subj = message.get('Subject', '(no subject)')
            Subj = self.decodeHeader(Subj)  # 3.0: send encodes
            if Subj[:5].lower() != 'fwd: ':  # 3.0: unify case
                Subj = 'Fwd: ' + Subj
            ForwardWindow(starttext=maintext,
                          headermap=dict(From=From, Subject=Subj, Bcc=From))

    def onSaveMailFile(self):
        """
        save selected emails to file for offline viewing;
        disabled if target file load/delete is in progress;
        disabled by getMessages if self is a busy file too;
        contSave not threaded: disables all other actions;
        """
        # Oct 2011, examples 1.3: test for blocking action before fileselect
        # dialog, else action's exit callback might invalidate selected message
        # numbers while file select dialog is open; getMessages blocks other
        # changes later, with modal nature of this code; see also onDelete below;

        if self.okayToSave():  # subclass specific test and error popup

            msgnums = self.selectedMsgs()
            if not msgnums:
                showerror(appname, 'No message selected')
            else:
                # caveat: dialog warns about replacing file
                filename = self.saveDialog.show()  # shared class attr
                if filename:  # don't verify num msgs
                    filename = os.path.abspath(filename)  # normalize / to \
                    self.getMessages(msgnums,
                                     after=lambda: self.contSave(msgnums, filename))

    def contSave(self, msgnums, filename):
        # test busy now, after poss srvr msgs load
        if (filename in openSaveFiles.keys() and  # viewing this file?
                openSaveFiles[filename].openFileBusy):  # load/del occurring?
            showerror(appname, 'Target file busy - cannot save')
        else:
            try:  # caveat:not threaded
                fulltextlist = []  # 3.0: use encoding
                mailfile = open(filename, 'a', encoding=mailconfig.fetchEncoding)
                for msgnum in msgnums:  # < 1sec for N megs
                    fulltext = self.getMessage(msgnum)  # but poss many msgs
                    if fulltext[-1] != '\n': fulltext += '\n'
                    mailfile.write(saveMailSeparator)
                    mailfile.write(fulltext)
                    fulltextlist.append(fulltext)
                mailfile.close()
            except:
                showerror(appname, 'Error during save')
                printStack(sys.exc_info())
            else:  # why .keys(): EIBTI
                if filename in openSaveFiles.keys():  # viewing this file?
                    window = openSaveFiles[filename]  # update list, raise
                    window.addSavedMails(fulltextlist)  # avoid file reload
                    # window.loadMailFileThread()           # this was very slow

    def onOpenMailFile(self, filename=None):
        # process saved mail offline
        filename = filename or self.openDialog.show()  # shared class attr
        if filename:
            filename = os.path.abspath(filename)  # match on full name
            if filename in openSaveFiles.keys():  # only 1 win per file
                openSaveFiles[filename].lift()  # raise file's window
                showinfo(appname, 'File already open')  # else deletes odd
            else:
                from .用于启动的主模块_PyMailGui import PyMailFileWindow  # avoid duplicate win
                popup = PyMailFileWindow(filename)  # new list window
                openSaveFiles[filename] = popup  # removed in quit
                popup.loadMailFileThread()  # try load in thread

    def onDeleteMail(self):
        """
        delete selected mails from server or file
        """
        # Oct 2011, examples 1.3: test for delete-in-progress before verification
        # pupup, else a prior delete's exit action may be run from an after() timer
        # event callback between pressing Delete and the verification dialog's OK,
        # invalidating selected message numbers (and possibly deleting wrong mails!);
        # a similar timing issue for the file selection dialog in Save was patched
        # too, but it seems much less harmful to save than delete incorrect mails;

        if self.okayToDelete():  # subclass specific test and error popup

            msgnums = self.selectedMsgs()  # subclass: fillIndex
            if not msgnums:  # always verify here
                showerror(appname, 'No message selected')
            else:
                if askyesno(appname, 'Verify delete %d mails?' % len(msgnums)):
                    self.doDelete(msgnums)

    ##################
    # utility methods
    ##################

    def selectedMsgs(self):
        # get messages selected in main listbox
        selections = self.listBox.curselection()  # tuple of digit strs, 0..N-1
        return [int(x) + 1 for x in selections]  # convert to ints, make 1..N

    warningLimit = 15

    def verifySelectedMsgs(self):
        msgnums = self.selectedMsgs()
        if not msgnums:
            showerror(appname, 'No message selected')
        else:
            numselects = len(msgnums)
            if numselects > self.warningLimit:
                if not askyesno(appname, 'Open %d selections?' % numselects):
                    msgnums = []
        return msgnums

    def fillIndex(self, maxhdrsize=25):
        """
        fill all of main listbox from message header mappings;
        3.0: decode headers per email/mime/unicode here if encoded;
        3.0: caveat: large chinese characters can break '|' alignment;
        """

        def makeTimeLocal(origTimeStr):
            """
            Oct 2011, examples 1.3: display sent-time relative to local
            timezone; must do after decode and for header size calc too;
            runs: formatdate(mktime_tz(parsedate_tz(there)), localtime=True)
            """
            from email.utils import formatdate
            from email._parseaddr import parsedate_tz, mktime_tz
            if origTimeStr in [' ', '']:
                return origTimeStr  # common case: parser fails

            try:
                timeTuple = parsedate_tz(origTimeStr)
                utcTimeNum = mktime_tz(timeTuple)
                localTimeStr = formatdate(utcTimeNum, localtime=True)
                return localTimeStr
            except:
                # import traceback; traceback.print_exc()
                # print('Local time failed:', sys.exc_info()[0], sys.exc_info()[1])
                return origTimeStr  # use orig date-time text if anything fails

        hdrmaps = self.headersMaps()  # may be empty
        showhdrs = ('Subject', 'From', 'Date', 'To')  # default hdrs to show
        if hasattr(mailconfig, 'listheaders'):  # mailconfig customizes
            showhdrs = mailconfig.listheaders or showhdrs
        addrhdrs = ('From', 'To', 'Cc', 'Bcc')  # 3.0: decode i18n specially

        # compute max field sizes <= hdrsize
        maxsize = {}
        for key in showhdrs:
            allLens = []  # too big for a list comp!
            for msg in hdrmaps:
                keyval = msg.get(key, ' ')
                if key == 'Date':
                    allLens.append(len(makeTimeLocal(self.decodeHeader(keyval))))
                elif key not in addrhdrs:
                    allLens.append(len(self.decodeHeader(keyval)))
                else:
                    allLens.append(len(self.decodeAddrHeader(keyval)))
            if not allLens: allLens = [1]
            maxsize[key] = min(maxhdrsize, max(allLens))

        # populate listbox with fixed-width left-justified fields
        self.listBox.delete(0, END)  # show multiparts with *
        for (ix, msg) in enumerate(hdrmaps):  # via content-type hdr
            msgtype = msg.get_content_maintype()  # no is_multipart yet
            msgline = (msgtype == 'multipart' and '*') or ' '
            msgline += '%03d' % (ix + 1)
            for key in showhdrs:
                mysize = maxsize[key]
                if key == 'Date':
                    keytext = makeTimeLocal(self.decodeHeader(msg.get(key, ' ')))
                elif key not in addrhdrs:
                    keytext = self.decodeHeader(msg.get(key, ' '))
                else:
                    keytext = self.decodeAddrHeader(msg.get(key, ' '))
                msgline += ' | %-*s' % (mysize, keytext[:mysize])
            msgline += '| %.1fK' % (self.mailSize(ix + 1) / 1024)  # 3.0: .0 optional
            self.listBox.insert(END, msgline)
        self.listBox.see(END)  # show most recent mail=last line

    def replyCopyTo(self, message):
        """
        3.0: replies copy all original recipients, by prefilling
        Cc header with all addreses in original To and Cc after
        removing duplicates and new sender;  could decode i18n addrs
        here, but the view window will decode to display (and send
        will reencode) and the unique set filtering here will work
        either way, though a sender's i18n address is assumed to be
        in encoded form in mailconfig (else it is not removed here);
        empty To or Cc headers are okay: split returns empty lists;
        """
        if not mailconfig.repliesCopyToAll:
            # reply to sender only
            Cc = ''
        else:
            # copy all original recipients (3.0)
            allRecipients = (self.splitAddresses(message.get('To', '')) +
                             self.splitAddresses(message.get('Cc', '')))
            uniqueOthers = set(allRecipients) - set([mailconfig.myaddress])
            Cc = ', '.join(uniqueOthers)
        return Cc or '?'

    def formatQuotedMainText(self, message):
        """
        3.0: factor out common code shared by Reply and Forward:
        fetch decoded text, extract text if html, line wrap, add > quote
        """
        type, maintext = self.findMainText(message)  # 3.0: decoded str
        if type in ['text/html', 'text/xml']:  # 3.0: get plain text
            maintext = html2text.html2text(maintext)
        maintext = wraplines.wrapText1(maintext, mailconfig.wrapsz - 2)  # 2 = '> '
        maintext = self.quoteOrigText(maintext, message)  # add hdrs, >
        if mailconfig.mysignature:
            maintext = ('\n%s\n' % mailconfig.mysignature) + maintext
        return maintext

    def quoteOrigText(self, maintext, message):
        """
        3.0: we need to decode any i18n (internationalizd) headers here too,
        or they show up in email+MIME encoded form in the quoted text block;
        decodeAddrHeader works on one addr or all in a comma-separated list;
        this may trigger full text encoding on sends, but the main text is
        also already in fully decoded form: could be in any Unicode scheme;
        """
        quoted = '\n-----Original Message-----\n'
        for hdr in ('From', 'To', 'Subject', 'Date'):
            rawhdr = message.get(hdr, '?')
            if hdr not in ('From', 'To'):
                dechdr = self.decodeHeader(rawhdr)  # full value
            else:
                dechdr = self.decodeAddrHeader(rawhdr)  # name parts only
            quoted += '%s: %s\n' % (hdr, dechdr)
        quoted += '\n' + maintext
        quoted = '\n' + quoted.replace('\n', '\n> ')
        return quoted

    ########################
    # subclass requirements
    ########################

    def getMessages(self, msgnums, after):  # used by view,save,reply,fwd
        after()  # redef if cache, thread test

    # plus okayToQuit?, okayToDelete?, okayToSave?, any unique actions
    def getMessage(self, msgnum):
        assert False  # used by many: full mail text

    def headersMaps(self):
        assert False  # fillIndex: hdr mappings list

    def mailSize(self, msgnum):
        assert False  # fillIndex: size of msgnum

    def doDelete(self):
        assert False  # onDeleteMail: delete button


###############################################################################
# main window - when viewing messages in local save file (or sent-mail file)
###############################################################################


class PyMailFile(PyMailCommon):
    """
    customize PyMailCommon for viewing saved-mail file offline;
    mixed with a Tk, Toplevel, or Frame, adds main mail listbox;
    maps load, fetch, delete actions to local text file storage;
    file opens and deletes here run in threads for large files;

    save and send not threaded, because only append to file; save
    is disabled if source or target file busy with load/delete;
    save disables load, delete, save just because it is not run
    in a thread (blocks GUI);

    TBD: may need thread and O/S file locks if saves ever do run in
    threads: saves could disable other threads with openFileBusy, but
    file may not be open in GUI;  file locks not sufficient, because
    GUI updated too;  TBD: appends to sent-mail file may require O/S
    locks: as is, user gets error pop up if sent during load/del;

    3.0: mail save files are now Unicode text, encoded per an encoding
    name setting in the mailconfig module; this may not support worst
    case scenarios of unusual or mixed encodings, but most full mail
    text is ascii, and the Python 3.1 email package is partly broken;
    """

    def actions(self):
        return [('Open', self.onOpenMailFile),
                ('Write', self.onWriteMail),
                ('  ', None),  # 3.0:  separators
                ('View', self.onViewFormatMail),
                ('Reply', self.onReplyMail),
                ('Fwd', self.onFwdMail),
                ('Save', self.onSaveMailFile),
                ('Delete', self.onDeleteMail),
                ('  ', None),
                ('Quit', self.quit)]

    def __init__(self, filename):
        # caller: do loadMailFileThread next
        PyMailCommon.__init__(self)
        self.filename = filename
        self.openFileBusy = threadtools.ThreadCounter()  # one per window

    def loadMailFileThread(self):
        """
        load or reload file and update window index list;
        called on Open, startup, and possibly on Send if
        sent-mail file appended is currently open;  there
        is always a bogus first item after the text split;
        alt: [self.parseHeaders(m) for m in self.msglist];
        could pop up a busy dialog, but quick for small files;

        2.1: this is now threaded--else runs < 1sec for N meg
        files, but can pause GUI N seconds if very large file;
        Save now uses addSavedMails to append msg lists for
        speed, not this reload;  still called from Send just
        because msg text unavailable - requires refactoring;
        delete threaded too: prevent open and delete overlap;
        """
        if self.openFileBusy:
            # don't allow parallel open/delete changes
            errmsg = 'Cannot load, file is busy:\n"%s"' % self.filename
            showerror(appname, errmsg)
        else:
            # self.listBox.insert(END, 'loading...')      # error if user clicks
            savetitle = self.title()  # set by window class
            self.title(appname + ' - ' + 'Loading...')
            self.openFileBusy.incr()
            threadtools.startThread(
                action=self.loadMailFile,
                args=(),
                context=(savetitle,),
                onExit=self.onLoadMailFileExit,
                onFail=self.onLoadMailFileFail)

    def loadMailFile(self):
        # run in a thread while GUI is active
        # open, read, parser may all raise excs: caught in thread utility
        file = open(self.filename, 'r', encoding=mailconfig.fetchEncoding)  # 3.0
        allmsgs = file.read()
        self.msglist = allmsgs.split(saveMailSeparator)[1:]  # full text
        self.hdrlist = list(map(self.parseHeaders, self.msglist))  # msg objects

    def onLoadMailFileExit(self, savetitle):
        # on thread success
        self.title(savetitle)  # reset window title to filename
        self.fillIndex()  # updates GUI: do in main thread
        self.lift()  # raise my window
        self.openFileBusy.decr()

    def onLoadMailFileFail(self, exc_info, savetitle):
        # on thread exception
        showerror(appname, 'Error opening "%s"\n%s\n%s' %
                  ((self.filename,) + exc_info[:2]))
        printStack(exc_info)
        self.destroy()  # always close my window?
        self.openFileBusy.decr()  # not needed if destroy

    def addSavedMails(self, fulltextlist):
        """
        optimization: extend loaded file lists for mails
        newly saved to this window's file; in past called
        loadMailThread to reload entire file on save - slow;
        must be called in main GUI thread only: updates GUI;
        sends still reloads sent file if open: no msg text;
        """
        self.msglist.extend(fulltextlist)
        self.hdrlist.extend(map(self.parseHeaders, fulltextlist))  # 3.x iter ok
        self.fillIndex()
        self.lift()

    def okayToSave(self):
        # Oct 2011: test before file selection popup
        if self.openFileBusy:
            # dont allow parallel open/delete changes
            errmsg = 'Cannot save, file is busy:\n"%s"' % self.filename
            showerror(appname, errmsg)
            return False
        else:
            return True

    def okayToDelete(self):
        # Oct 2011: test before verification popup too
        if self.openFileBusy:
            # dont allow parallel open/delete changes
            errmsg = 'Cannot delete, file is busy:\n"%s"' % self.filename
            showerror(appname, errmsg)
            return False
        else:
            return True

    def doDelete(self, msgnums):
        """
        simple-minded, but sufficient: rewrite all
        nondeleted mails to file; can't just delete
        from self.msglist in-place: changes item indexes;
        Py2.3 enumerate(L) same as zip(range(len(L)), L)
        2.1: now threaded, else N sec pause for large files
        """
        if self.openFileBusy:  # test probably not needed here too, but harmless
            # dont allow parallel open/delete changes
            errmsg = 'Cannot delete, file is busy:\n"%s"' % self.filename
            showerror(appname, errmsg)
        else:
            savetitle = self.title()
            self.title(appname + ' - ' + 'Deleting...')
            self.openFileBusy.incr()
            threadtools.startThread(
                action=self.deleteMailFile,
                args=(msgnums,),
                context=(savetitle,),
                onExit=self.onDeleteMailFileExit,
                onFail=self.onDeleteMailFileFail)

    def deleteMailFile(self, msgnums):
        # run in a thread while GUI active
        indexed = enumerate(self.msglist)
        keepers = [msg for (ix, msg) in indexed if ix + 1 not in msgnums]
        allmsgs = saveMailSeparator.join([''] + keepers)
        file = open(self.filename, 'w', encoding=mailconfig.fetchEncoding)  # 3.0
        file.write(allmsgs)
        self.msglist = keepers
        self.hdrlist = list(map(self.parseHeaders, self.msglist))

    def onDeleteMailFileExit(self, savetitle):
        self.title(savetitle)
        self.fillIndex()  # updates GUI: do in main thread
        self.lift()  # reset my title, raise my window
        self.openFileBusy.decr()

    def onDeleteMailFileFail(self, exc_info, savetitle):
        showerror(appname, 'Error deleting "%s"\n%s\n%s' %
                  ((self.filename,) + exc_info[:2]))
        printStack(exc_info)
        self.destroy()  # always close my window?
        self.openFileBusy.decr()  # not needed if destroy

    def getMessages(self, msgnums, after):
        """
        used by view,save,reply,fwd: file load and delete
        threads may change the msg and hdr lists, so disable
        all other operations that depend on them to be safe;
        this test is for self: saves also test target file;
        """
        if self.openFileBusy:
            errmsg = 'Cannot fetch, file is busy:\n"%s"' % self.filename
            showerror(appname, errmsg)
        else:
            after()  # mail already loaded

    def getMessage(self, msgnum):
        return self.msglist[msgnum - 1]  # full text of 1 mail

    def headersMaps(self):
        return self.hdrlist  # email.message.Message objects

    def mailSize(self, msgnum):
        return len(self.msglist[msgnum - 1])

    def quit(self):
        # don't destroy during update: fillIndex next
        if self.openFileBusy:
            showerror(appname, 'Cannot quit during load or delete')
        else:
            if askyesno(appname, 'Verify Quit Window?'):
                # delete file from open list
                del openSaveFiles[self.filename]
                Toplevel.destroy(self)


###############################################################################
# 主窗口——查看邮件服务器上的邮件时
###############################################################################


class PyMailServer(PyMailCommon):
    """
    自定义PyMailCommon以查看仍在服务器上的邮件；
    融合了Tk、Toplevel或Frame，增加了主邮件列表框；
    为邮件服务器收件箱的加载，获取和删除行添加相应代码；
    嵌入了MessageCache，后者时mailtools中的MailFetcher的一个实例；
    """

    def actions(self):
        return [('Load', self.onLoadServer),
                ('Open', self.onOpenMailFile),
                ('Write', self.onWriteMail),
                ('  ', None),                                               # 3.0:  分隔符
                ('View', self.onViewFormatMail),
                ('Reply', self.onReplyMail),
                ('Fwd', self.onFwdMail),
                ('Save', self.onSaveMailFile),
                ('Delete', self.onDeleteMail),
                ('  ', None),
                ('Quit', self.quit)]

    def __init__(self):
        PyMailCommon.__init__(self)
        self.cache = messagecache.GuiMessageCache()                         # 嵌入的，而不是继承的

    # self.listBox.insert(END, 'Press Load to fetch mail')

    def makeWidgets(self):                                                  # 帮助栏：只在主窗口中显现
        self.addHelpBar()
        PyMailCommon.makeWidgets(self)

    def addHelpBar(self):
        msg = 'PyMailGUI - a Python/tkinter email client  (help)'
        title = Button(self, text=msg)
        title.config(bg='steelblue', fg='white', relief=RIDGE)
        title.config(command=self.onShowHelp)
        title.pack(fill=X)                                                  # side未指定的话则默认为TOP所以看到会出现在顶端

    def onShowHelp(self):
        """
        加载，显示文本块字符串
        mailconfig里的用户设置可以选择文本，HTML或两者兼而有之
        往往只显示其中一个：如果两者都设为false，则显示html
        """
        if mailconfig.showHelpAsText:
            from .用户帮助文本与显示_PyMailGUIHelp import helptext
            popuputil.HelpPopup(appname, helptext, showsource=self.onShowMySource)

        if mailconfig.showHelpAsHTML or (not mailconfig.showHelpAsText):
            from .用户帮助文本与显示_PyMailGUIHelp import showHtmlHelp
            showHtmlHelp()                                                  # 3.0: 没有源文件链接的HTML版本

    def onShowMySource(self, showAsMail=False):
        """
        显示我的源代码文件，以及在此处和别处所导入的模块
        """
        from . import 用于启动的主模块_PyMailGui as PyMailGui
        from . import 邮件列表窗口_ListWindows as ListWindows                                 # 这里导了本身进来
        from . import 邮件查看窗口_ViewWindows as ViewWindows
        from . import 程序范围内的全局对象_SharedNames as SharedNames
        from . import 定制PyEdit弹出窗口_textConfig as textConfig
        from .需要复用的_作者之前写过的各种用例.服务器的发送_接收_解析及开发程序包_mailtools import (  # mailtools现在是个程序包
            mailSender, mailFetcher, mailParser)  # 不能在def里使用 import *
        mymods = (
            PyMailGui, ListWindows, ViewWindows, SharedNames,
            PyMailGuiHelp, popuputil, messagecache, wraplines, html2text,
            mailtools, mailFetcher, mailSender, mailParser,
            mailconfig, textConfig, threadtools, windows, textEditor)
        for mod in mymods:
            source = mod.__file__
            if source.endswith('.pyc'):
                source = source[:-4] + '.py'                                            # 假设同目录下有.py文件
            if showAsMail:
                # 这么做好像有点cheesey了，也就是过分夸张、用力过猛的意思...
                code = open(source).read()                                              # 3.0: 平台编码
                user = mailconfig.myaddress
                hdrmap = {'From': appname, 'To': user, 'Subject': mod.__name__}
                ViewWindow(showtext=code,
                           headermap=hdrmap,
                           origmessage=email.message.Message())
            else:
                # 使用更好用的PyEdit文本编辑器
                # 假设utf8 unicode进行编码（否则PyEdit会询问）
                wintitle = ' - ' + mod.__name__
                textEditor.TextEditorMainPopup(self, source, wintitle, 'utf-8')

    def onLoadServer(self, forceReload=False):
        """
        在派生线程里运行：根据请求加载或重载邮件题头列表；
        退出、失败和处理经回调队列调度后由threadChecker负责执行；
        加载操作可能会与发送操作同时发生，但禁用发送之外的其他操作；
        可以与loadingMsgs同时发生，但可能会改变邮件缓存列表；
        在删除/同步失败时执行forceReload，否则只能加载最新收到的邮件；
        """
        if loadingHdrsBusy or deletingBusy or loadingMsgsBusy:
            showerror(appname, '在加载或删除期间无法加载题头')
        else:
            loadingHdrsBusy.incr()
            self.cache.setPopPassword(appname)                                      # 不要在这个线程里更新GUI
            popup = popuputil.BusyBoxNowait(appname, 'Loading message headers')
            threadtools.startThread(
                action=self.cache.loadHeaders,
                args=(forceReload,),
                context=(popup,),
                onExit=self.onLoadHdrsExit,
                onFail=self.onLoadHdrsFail,
                onProgress=self.onLoadHdrsProgress)

    def onLoadHdrsExit(self, popup):
        self.fillIndex()
        popup.quit()
        self.lift()
        loadingHdrsBusy.decr()  # allow other actions to run

    def onLoadHdrsFail(self, exc_info, popup):
        popup.quit()
        showerror(appname, 'Load failed: \n%s\n%s' % exc_info[:2])
        printStack(exc_info)  # send stack trace to stdout
        loadingHdrsBusy.decr()
        if exc_info[0] == mailtools.MessageSynchError:  # synch inbox/index
            self.onLoadServer(forceReload=True)  # new thread: reload
        else:
            self.cache.popPassword = None  # force re-input next time

    def onLoadHdrsProgress(self, i, n, popup):
        popup.changeText('%d of %d' % (i, n))

    def okayToSave(self):
        # Oct 2011: test before file selection popup
        if loadingHdrsBusy or deletingBusy or loadingMsgsBusy:
            showerror(appname, 'Cannot save during load or delete')
            return False
        else:
            return True

    def okayToDelete(self):
        # Oct 2011: test before verification popup too
        if loadingHdrsBusy or deletingBusy or loadingMsgsBusy:
            showerror(appname, 'Cannot delete during load or delete')
            return False
        else:
            return True

    def doDelete(self, msgnumlist):
        """
        threaded: delete from server now - changes msg nums;
        may overlap with sends only, disables all except sends;
        2.1: cache.deleteMessages now checks TOP result to see
        if headers match selected mails, in case msgnums out of
        synch with mail server: poss if mail deleted by other client,
        or server deletes inbox mail automatically - some ISPs may
        move a mail from inbox to undeliverable on load failure;
        """
        if loadingHdrsBusy or deletingBusy or loadingMsgsBusy:  # retest is harmless
            showerror(appname, 'Cannot delete during load or delete')
        else:
            deletingBusy.incr()
            popup = popuputil.BusyBoxNowait(appname, 'Deleting selected mails')
            threadtools.startThread(
                action=self.cache.deleteMessages,
                args=(msgnumlist,),
                context=(popup,),
                onExit=self.onDeleteExit,
                onFail=self.onDeleteFail,
                onProgress=self.onDeleteProgress)

    def onDeleteExit(self, popup):
        self.fillIndex()  # no need to reload from server
        popup.quit()  # refill index with updated cache
        self.lift()  # raise index window, release lock
        deletingBusy.decr()

    def onDeleteFail(self, exc_info, popup):
        popup.quit()
        showerror(appname, 'Delete failed: \n%s\n%s' % exc_info[:2])
        printStack(exc_info)
        deletingBusy.decr()  # delete or synch check failure
        self.onLoadServer(forceReload=True)  # new thread: some msgnums changed

    def onDeleteProgress(self, i, n, popup):
        popup.changeText('%d of %d' % (i, n))

    def getMessages(self, msgnums, after):
        """
        threaded: prefetch all selected messages into cache now;
        used by save, view, reply, and forward to prefill cache;
        may overlap with other loadmsgs and sends, disables delete,load;
        only runs "after" action if the fetch allowed and successful;
        2.1: cache.getMessages tests if index in synch with server,
        but we only test if we have to go to server, not if cached;

        3.0: see messagecache note: now avoids potential fetch of mail
        currently being fetched, if user clicks again while in progress;
        any message being fetched by any other request in progress must
        disable entire toLoad batch: else, need to wait for N other loads;
        fetches are still allowed to overlap in time, as long as disjoint;
        """
        if loadingHdrsBusy or deletingBusy:
            showerror(appname, 'Cannot fetch message during load or delete')
        else:
            toLoad = [num for num in msgnums if not self.cache.isLoaded(num)]
            if not toLoad:
                after()  # all already loaded
                return  # process now, no wait pop up
            else:
                if set(toLoad) & self.beingFetched:  # 3.0: any in progress?
                    showerror(appname, 'Cannot fetch any message being fetched')
                else:
                    self.beingFetched |= set(toLoad)
                    loadingMsgsBusy.incr()
                    from .通用的GUI弹出窗口_popuputil import BusyBoxNowait
                    popup = BusyBoxNowait(appname, 'Fetching message contents')
                    threadtools.startThread(
                        action=self.cache.getMessages,
                        args=(toLoad,),
                        context=(after, popup, toLoad),
                        onExit=self.onLoadMsgsExit,
                        onFail=self.onLoadMsgsFail,
                        onProgress=self.onLoadMsgsProgress)

    def onLoadMsgsExit(self, after, popup, toLoad):
        self.beingFetched -= set(toLoad)
        popup.quit()
        after()
        loadingMsgsBusy.decr()  # allow other actions after onExit done

    def onLoadMsgsFail(self, exc_info, after, popup, toLoad):
        self.beingFetched -= set(toLoad)
        popup.quit()
        showerror(appname, 'Fetch failed: \n%s\n%s' % exc_info[:2])
        printStack(exc_info)
        loadingMsgsBusy.decr()
        if exc_info[0] == mailtools.MessageSynchError:  # synch inbox/index
            self.onLoadServer(forceReload=True)  # new thread: reload

    def onLoadMsgsProgress(self, i, n, after, popup, toLoad):
        popup.changeText('%d of %d' % (i, n))

    def getMessage(self, msgnum):
        return self.cache.getMessage(msgnum)  # full mail text

    def headersMaps(self):
        # list of email.message.Message objects, 3.x requires list() if map()
        # return [self.parseHeaders(h) for h in self.cache.allHdrs()]
        return list(map(self.parseHeaders, self.cache.allHdrs()))

    def mailSize(self, msgnum):
        return self.cache.getSize(msgnum)

    def okayToQuit(self):
        # any threads still running?
        filesbusy = [win for win in openSaveFiles.values() if win.openFileBusy]
        busy = loadingHdrsBusy or deletingBusy or sendingBusy or loadingMsgsBusy
        busy = busy or filesbusy
        return not busy
